實作 PHP API & 留言板 SPA (番外篇:實作載入更多功能)


Posted by Nicolakacha on 2020-09-12

上次做完了 PHP API 和 留言板 SPA:
實作 PHP API & 留言板 SPA(上)
實作 PHP API & 留言板 SPA(下)

但其實 DEMO 裡面有一個功能沒有講到,就是「載入更多」的功能,我們不希望一打開留言板就看到全部的貼文,而是只顯示最新的 5 則留言,每次按下載入更多按鈕的時候,再往前讀取 5 則,直到所有留言都顯示出來為止。

首先要回去看一下原本的架構,分為載入留言和新增留言兩部份:

$(document).ready(() => {
  const commentsDOM = $('.comments');
  getComments(commentsDOM);

  $('.add-comment-form').submit((e) => {
    e.preventDefault();
    addComment(commentsDOM);
  });
});

現在我們想要在每次點擊 Read More 的時候,都透過 getComments 拿 5 筆資料。可以先把架構寫好,再來思考怎麼修改 getComments,讓每次都可以往前拿到 5 筆資料:

  $('.load-more').click(() => {
    getComments(commentsDOM);
  });

留言板只要顯示最新的 5 則留言,所以要在後端 api_comments.php 和資料庫拿資料 SQL 加上 LIMIT 5 的限制
api_comments.php

$sql = "SELECT nickname, content, created_at, id FROM nicolakacha_discussion WHERE site_key =? ORDER BY id DESC LIMIT 5";
$stmt = $conn->prepare($sql);
$stmt->bind_param('s', $siteKey);

這樣每次就只會拿到最後 5 則留言,但是,但是,要怎麼每次點擊都往前推回 5 個呢?這就是這次的重點啦 —— Cursor based pagination!

Cursor based pagination

把畫面上最早的那則留言當作指標,每次載入小於指標的 5 則留言
因為我們每則留言都有自動增加(auto increment) 所產生的 id,我們可以把畫面上尾端的留言的 id 當作指標,並再每次拿到這個指標之前的 5 則留言。

舉例來說,假設現在有 15 則留言,畫面上的最舊的一筆留言的 id 是 11,就把它當作指標,載入 id < 11 的 5 則留言,也就是 id 6~10 的留言。
載入 6~10 的留言之後,現在畫面上最早的一則留言變成了 id 6 的留言,再次把它當成指標,指標指到了 6,就載入 id < 6 的 5 則留言,也就是 id 1~5 的留言。
透過這種方法來做到的分頁機制,就是 Cursor based pagination,簡單來說,就是把最前一個資料當成 Cursor,每次往前查詢的做法。

如果更前面沒有留言可以拿了,就隱藏 read more 的按紐,這樣就完成了載入更多的功能呢。
但...但是,事情總是沒這麼單純,要怎麼知道前面還有沒有資料可以拿???

往前打聽一下

把後端從資料庫拿留言的筆數改成 6 筆 (LIMIT 6),讓前端每次可以拿 6 則留言,但我們只顯示 5 則,指標一樣是畫面上的最後一則,多拿的那則就是為了偷偷打聽一下前面還有沒有留言可以拿。
狀況一:如果前端拿到了 6 筆,代表我們至少還有一則留言是沒有被載入的,可以繼續讓 read more 按鈕顯示在畫面上。
狀況二:如果前端拿到的留言小於 6 筆,代表更前面已經沒有留言可以拿了,則隱藏 read more 按鈕。

透過這樣的方式,我們就可以在即使不知道留言總數的情況之下,也能正確顯示 read more 按鈕,接下來就來把概念實作成程式碼吧:

一開始我們並不需要 cursor,只要知道第一次是拿到的留言數是不是小於 6,來判斷要不要有 read more 按鈕,如果是拿到 6,代表需要 read more 按鈕,我們就把最後一則留言的 id 當作 cursor。如果一開始拿到的留言數就小於 6,我們也不需要 read more 按鈕了。

如果有 cursor,代表還有留言可以載入,所以每次點擊 read more 的時候,就把 cursor 傳給後端以修改 SQL query。寫成程式碼的樣子就會像這樣:

 if (!empty($_GET['cursor'])) {
    $cursor = $_GET['cursor'];
  }
    // 點擊 read more 的載入
  if (!empty($_GET['cursor'])) {
    $sql = "SELECT nickname, content, created_at, id FROM nicolakacha_discussion WHERE (site_key =? AND id < ?) ORDER BY id DESC LIMIT 6";
    $stmt = $conn->prepare($sql);
    $stmt->bind_param('si', $siteKey, $cursor);
    // 一開始的載入
  } else {
    $sql = "SELECT nickname, content, created_at, id FROM nicolakacha_discussion WHERE site_key =? ORDER BY id DESC LIMIT 6";
    $stmt = $conn->prepare($sql);
    $stmt->bind_param('s', $siteKey);
  };

而前端的 JavaScript,就是要幫我們在每次拿到留言時,檢查留言的筆數是不是小於六則,如果小於 6 則我們就不藏了,不管拿到的是一則兩則還是五則留言,全部都顯示出來吧!,如果等於六 則,就只顯示前五則,最後一則用來偵測,順便把 cursor 這個變數重新賦值成畫面上最前一筆(第五則)留言的 id。

function getComments(commentsDOM) {
  getCommentsAPI(cursor, (data) => {
    if (!data.ok) {
      console.log(data.message);
      return;
    }
    // Get 6 comments at first but don't render the last one. The last one is for checking purpose.
    const comments = data.discussion;
    // If comments < 6, there is no more to get
    // then render all comments and also hide the load more button.
    if (comments.length < 6) {
      for (let i = 0; i < comments.length; i += 1) {
        appendCommentToDOM(commentsDOM, comments[i]);
      }
      $('.load-more').hide();
      // if comments >= 6, we only render the first 5 of them.
    } else {
      for (let i = 0; i < comments.length - 1; i += 1) {
        appendCommentToDOM(commentsDOM, comments[i]);
      }
      // Set the 2nd last comment's id as cursor.
      cursor = comments[comments.length - 2].id;
      console.log(cursor);
    }
  });
}

如果有 cursor,點擊 read more 跟後端要資料的時候,那麼就把 cursor 告訴後端吧:

function getCommentsAPI(cursorDefault, cb) {
  let url = `${APIUrl}/api_comments.php?site_key=nicolas`;
  if (cursorDefault) {
    url += `&cursor=${cursor}`;
  }
  $.ajax({ url })
    .done(data => cb(data))
    .fail(err => console.log(err));
}

總結

在前面還有留言的時候,顯示 read more 按鈕,在沒有留言的時候則隱藏,這樣一來我們 cursor based pagination 的載入更多功能就完成啦!這種分頁做法的優點是查詢起來效能較好,增加和新增資料也不會影響查詢結果。但是就不能像一般的頁碼一樣跳到指定的某一頁,因為根本沒有固定的頁,一定要從頭或從尾開始遍歷才行。

完整 script.js 參考

const APIUrl = 'http://mentor-program.co/mtr04group1/Nicolakacha/week12/board';
let cursor = null;

function encodeHTML(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}

function addComment(commentsDOM) {
  const nickname = $('input[name=nickname]').val().trim();
    const content = $('textarea[name=content]').val().trim();
    if (nickname === '' || content === '') {
      console.log('not completed');
      $('.alert').remove();
      const remindMsg = '<div class="alert alert-danger mt-5" role="alert">Please complete both nickname and content!</div>';
      $('.main').prepend(remindMsg);
      return;
    }
    const newComment = {
      site_key: '123',
      nickname,
      content,
    };
    $.ajax({
      type: 'POST',
      url: `${APIUrl}/api_add_comments.php`,
      data: newComment,
    })
      .done((data) => {
        console.log(data);
        newComment.created_at = data.created_at;
        appendCommentToDOM(commentsDOM, newComment, true);
        $('.form-control').val('');
        $('.alert').remove();
      })
      .fail(err => console.log(err));
}

function appendCommentToDOM(container, comment, isPrepend) {
  const html = `
    <div class="card m-2">
      <div class="card-body">
        <div class="card-top d-flex">
          <h5 class="card-title"><i class="fa fa-user" aria-hidden="true"></i>&nbsp;&nbsp;${encodeHTML(comment.nickname)}</h5>
          <p class="card-text time">${comment.created_at}</p>
        </div>
        <p class="card-text content">${encodeHTML(comment.content)}</p>
        <input hidden value="${comment.id}"/>
      </div>
    </div>`;
  if (isPrepend) {
    container.prepend(html);
  } else {
    container.append(html);
  }
}

function getCommentsAPI(cursorDefault, cb) {
  let url = `${APIUrl}/api_comments.php?site_key=nicolas`;
  if (cursorDefault) {
    url += `&cursor=${cursor}`;
  }
  $.ajax({ url })
    .done(data => cb(data))
    .fail(err => console.log(err));
}

function getComments(commentsDOM) {
  getCommentsAPI(cursor, (data) => {
    if (!data.ok) {
      console.log(data.message);
      return;
    }
    // Get 6 comments at first but don't render the last one. The last one is for checking purpose.
    const comments = data.discussion;
    // If comments < 6, there is no more to get
    // then render all comments and also hide the load more button.
    if (comments.length < 6) {
      for (let i = 0; i < comments.length; i += 1) {
        appendCommentToDOM(commentsDOM, comments[i]);
      }
      $('.load-more').hide();
      // if comments >= 6, we only render the first 5 of them.
    } else {
      for (let i = 0; i < comments.length - 1; i += 1) {
        appendCommentToDOM(commentsDOM, comments[i]);
      }
      // Set the 2nd last comment's id as cursor.
      cursor = comments[comments.length - 2].id;
      console.log(cursor);
    }
  });
}

$(document).ready(() => {
  const commentsDOM = $('.comments');
  getComments(commentsDOM);

  $('.add-comment-form').submit((e) => {
    e.preventDefault();
    addComment(commentsDOM);
  });

    $('.load-more').click(() => {
    getComments(commentsDOM);
  });
});

API做翻页的两种思路
How to do Pagination?


#PHP #API #cursor based pagination







Related Posts

JS30 Day 13 筆記

JS30 Day 13 筆記

Bicycle Kinematic Model 實作小筆記

Bicycle Kinematic Model 實作小筆記

Export and import MongoDB collection

Export and import MongoDB collection


Comments